EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止させてみた
定期起動・停止するEC2インスタンスをタグで指定したい
こんにちは、のんピ(@non____97)です。
皆さんは定期起動・停止するEC2インスタンスをタグで指定したいなと思ったことはありますか? 私はあります。
EventBridge SchedulerでStopInstancesやStartInstancesを定期実行することで、簡単にEC2インスタンスを定期起動・停止させることが可能です。
ただし、どちらのAPIもインスタンスIDを指定する必要があります。
インスタンスIDを指定するとなると、対象となるインスタンスが増えた場合やリストアした際にインスタンスIDが変更となった場合など都度EventBridge Schedulerのペイロードを変更する必要があるため、非常に手間です。タグを使ってなるべく楽に指定したいところです。
そこで、EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止する仕組みを実装してみました。
処理のフロー
指定したタグを全て含む場合と、指定したタグのいずれかが付与されている場合のどちらにも対応できるようにします。
Step Functionsのワークフローは以下のとおりです。
- ステートマシンのインプットで
and
(指定したタグを全て含む場合)か、or
(指定したタグのいずれかが付与されている場合)のどちらか判定 - 以下のいずれのか処理を実施
- 指定したタグを全て含む場合、Filterにインプットで指定された値を指定して
DescribeInstances
を実行し、インスタンスIDの配列と配列の長さを結果に保存 - 指定したタグのいずれかが付与されている場合、インプットで指定されたタグでループして
DescribeInstances
を実行、インスタンスIDの配列配列の長さを結果に保存
- 指定したタグを全て含む場合、Filterにインプットで指定された値を指定して
- 配列の長さが0の場合は、該当するEC2インスタンスが存在しないと判断し、処理を終了
- ステートマシンのインプットで指定した
Action
がStop
の場合、StopInstances
を実行 - ステートマシンのインプットで指定した
Action
がStart
の場合、StartpInstances
を実行
図示すると以下の通りです。
AWS CDKでデプロイしました。使用したコードは以下リポジトリに保存しています。
ステートマシン周りは以下の通りです。
import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; export interface SfnProps {} export class Sfn extends Construct { readonly stateMachine: cdk.aws_stepfunctions.StateMachine; constructor(scope: Construct, id: string, props?: SfnProps) { super(scope, id); // 複数のタグが付与されている場合はループさせる // ループされた結果はフラットな配列に変換 // インスタンスIDはユニークになるように設定 // マッチするインスタンスが存在したかどうか判断するために length を結果に追加 const mapTags = new cdk.aws_stepfunctions.Map(this, "MapTags", { itemsPath: cdk.aws_stepfunctions.JsonPath.stringAt("$.Tags.or"), resultSelector: { InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt( "States.ArrayUnique($[*][*][*])" ), length: cdk.aws_stepfunctions.JsonPath.stringAt( "States.ArrayLength(States.ArrayUnique($[*][*][*]))" ), }, }); // 指定したタグが付与されているEC2 InstanceのID取得 const instanceIdsTagSummation = new cdk.aws_stepfunctions_tasks.CallAwsService( this, "DescribeInstancesTagSummation", { service: "ec2", action: "describeInstances", iamResources: ["*"], resultSelector: { InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt( "$.Reservations[*].Instances[*].InstanceId" ), }, parameters: { Filters: [ { Name: cdk.aws_stepfunctions.JsonPath.stringAt( "States.Format('tag:{}', $.Key)" ), Values: cdk.aws_stepfunctions.JsonPath.stringAt("$.Values"), }, ], }, } ); // 複数の指定したタグが付与されているEC2 InstanceのID取得 const instanceIdsTagProduct = new cdk.aws_stepfunctions_tasks.CallAwsService( this, "DescribeInstancesTagProduct", { service: "ec2", action: "describeInstances", iamResources: ["*"], resultSelector: { InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt( "$.Reservations[*].Instances[*].InstanceId" ), length: cdk.aws_stepfunctions.JsonPath.stringAt( "States.ArrayLength($.Reservations[*].Instances[*].InstanceId)" ), }, parameters: { Filters: cdk.aws_stepfunctions.JsonPath.stringAt("$.Tags.and"), }, } ); // タグがAND か OR かの判定 const choiceTag = new cdk.aws_stepfunctions.Choice(this, "ChoiceTag") .when(cdk.aws_stepfunctions.Condition.isPresent("$.Tags.or"), mapTags) .when( cdk.aws_stepfunctions.Condition.isPresent("$.Tags.and"), instanceIdsTagProduct ); // EC2 Instanceの停止 const stopInstances = new cdk.aws_stepfunctions_tasks.CallAwsService( this, "StopInstances", { service: "ec2", action: "stopInstances", iamResources: ["*"], parameters: { InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt("$.InstanceIds"), }, } ); // EC2 Instanceの起動 const startInstances = new cdk.aws_stepfunctions_tasks.CallAwsService( this, "StartInstances", { service: "ec2", action: "startInstances", iamResources: ["*"], parameters: { InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt("$.InstanceIds"), }, } ); // 指定した条件にマッチするEC2 Instanceが存在しない場合用のステート const pass = new cdk.aws_stepfunctions.Pass(this, "Pass"); // EC2 Instanceの起動 or 停止 const choiceAction = new cdk.aws_stepfunctions.Choice(this, "ChoiceAction") .when( // cdk.aws_stepfunctions.Condition.stringEquals("$.InstanceIds", ""), cdk.aws_stepfunctions.Condition.numberEquals("$.length", 0), pass ) .when( cdk.aws_stepfunctions.Condition.stringEquals( "$$.Execution.Input.Action", "Stop" ), stopInstances ) .when( cdk.aws_stepfunctions.Condition.stringEquals( "$$.Execution.Input.Action", "Start" ), startInstances ); // ワークフローの定義 const definition = choiceTag; mapTags.iterator(instanceIdsTagSummation).next(choiceAction); instanceIdsTagProduct.next(choiceAction); pass.endStates; // StepFunctions ステートマシンの作成 this.stateMachine = new cdk.aws_stepfunctions.StateMachine( this, "Default", { definitionBody: cdk.aws_stepfunctions.DefinitionBody.fromChainable(definition), timeout: cdk.Duration.minutes(5), } ); } }
ASLにすると以下の通りです。
{ "StartAt": "ChoiceTag", "States": { "ChoiceTag": { "Type": "Choice", "Choices": [ { "Variable": "$.Tags.or", "IsPresent": true, "Next": "MapTags" }, { "Variable": "$.Tags.and", "IsPresent": true, "Next": "DescribeInstancesTagProduct" } ] }, "MapTags": { "Type": "Map", "Next": "ChoiceAction", "ResultSelector": { "InstanceIds.$": "States.ArrayUnique($[*][*][*])", "length.$": "States.ArrayLength(States.ArrayUnique($[*][*][*]))" }, "Iterator": { "StartAt": "DescribeInstancesTagSummation", "States": { "DescribeInstancesTagSummation": { "End": true, "Type": "Task", "ResultSelector": { "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId" }, "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances", "Parameters": { "Filters": [ { "Name.$": "States.Format('tag:{}', $.Key)", "Values.$": "$.Values" } ] } } } }, "ItemsPath": "$.Tags.or" }, "ChoiceAction": { "Type": "Choice", "Choices": [ { "Variable": "$.length", "NumericEquals": 0, "Next": "Pass" }, { "Variable": "$$.Execution.Input.Action", "StringEquals": "Stop", "Next": "StopInstances" }, { "Variable": "$$.Execution.Input.Action", "StringEquals": "Start", "Next": "StartInstances" } ] }, "DescribeInstancesTagProduct": { "Next": "ChoiceAction", "Type": "Task", "ResultSelector": { "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId", "length.$": "States.ArrayLength($.Reservations[*].Instances[*].InstanceId)" }, "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances", "Parameters": { "Filters.$": "$.Tags.and" } }, "Pass": { "Type": "Pass", "End": true }, "StopInstances": { "End": true, "Type": "Task", "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances", "Parameters": { "InstanceIds.$": "$.InstanceIds" } }, "StartInstances": { "End": true, "Type": "Task", "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances", "Parameters": { "InstanceIds.$": "$.InstanceIds" } } }, "TimeoutSeconds": 300 }
ポイントは以下です。
- Mapの結果の配列をフラットにする
- 配列が空かどうか判断するために、前段のステートで配列の長さを計算しておく
1つ目について、Mapで何もしなければ、以下のように結果が配列で返ってきてしまいます。この形だと後続でもループさせる必要があったりと処理が面倒です。
[ { "InstanceIds": [ "インスタンスID", ] }, { "InstanceIds": [ "インスタンスID", ] } ]
そのため、ResultSelector
で$[*][*][*]
として配列をフラットにしてあげます。
こちらはAWS公式ドキュメントにも記載があります。
マッピングステートマシンの 並行 or ステートが配列を返す場合は、ResultSelectorそれらをフィールドを含むフラット配列に変換できます。このフィールドをパラレルまたはマップステートの定義に含めると、これらのステートの結果を操作できます。
配列をフラット化するには、次の例に示すように、[*]ResultSelectorフィールドで JMESPath 構文を使用します。
"ResultSelector": { "flattenArray.$": "$[*][*]" }
2つ目については、Choiceの使用として判定に使用する変数は必ずJSONパスである必要があります。変数にStates.ArrayLength($.InstanceIds)
と値を指定することはできません。
そのため、前段のステートのResultSelector
でStates.ArrayLength(States.ArrayUnique($[*][*][*]))
などと配列の長さを計算してあげています。
動作確認
動作確認をします。
EventBridge Schedulerで以下のようなスケジュールを設定します。
0/10
分ごとにInstance : Instance A
またはtest key : test value
のいずれかのタグが付与されている場合はEC2インスタンスを停止{ "Tags": { "or": [ { "Key": "Instance", "Values": [ "Instance A" ] }, { "Key": "test key", "Values": [ "test value" ] } ] }, "Action": "Stop" }
5/10
分ごとにInstance : Instance A
とtest key : test value
のタグがどちらも付与されている場合はEC2インスタンスを起動{ "Tags": { "and": [ { "Name": "tag:Instance", "Values": [ "Instance A" ] }, { "Name": "tag:test key", "Values": [ "test value" ] } ] }, "Action": "Start" }
検証で使用するEC2インスタンスの一覧は以下の通りです。
11:40になりました。EC2インスタンスが停止しているか確認しましょう。
いずれのEC2インスタンスにもtest key : test value
のタグが付与されているため、全てのEC2インスタンスが停止していますね。
ワークフローも確認します。
タグでDescribeInstancesTagSummation
をMapしてStopInstances
を実行していることが分かります。また、StopInstances
の入力としてインスタンスIDとインスタンスIDの数が指定されていますね。
11:45になりました。EC2インスタンスが起動していることを確認します。
Instance : Instance A
とtest key : test value
のタグがどちらも付与されているEC2インスタンスのみ起動していますね。
ワークフローも確認します。
タグでDescribeInstancesTagProduct
を実行した後、StartInstances
を実行していることが分かります。また、StartInstances
の入力としてインスタンスIDとインスタンスIDの数が指定されていますね。
Step Functionsを組み合わせば痒い所に手が届く
EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止させてみました。
Step Functionsをうまく使えば痒い所に手が届くので非常に便利ですね。
ちなみに、opswitchを使えば、このような作り込みをしなくとも対応可能です。「Step Functionsで頑張るのもな...」という方は是非触ってみてください。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!